# Title: Reduce GPU Usage Automatically # Description: Runs three levers in order - End WinSAT, Pause capture and overlay # services, and Lower graphics priority. WinSAT re-triggers itself at the next # maintenance window and the capture services are demand start, so those two revert # on their own. The graphics priority change persists for the life of the target # process and resets only when that process next restarts, so it is bounded but is # not actively restored by this tier. # # No double quotes and no semicolons are used anywhere in this file. The agent # script runner wraps content in double quotes, so an inner double quote ends # the wrapper early and any following text is run as a command. The interop # here-string below needs both characters, so they are injected via [char]34 # and [char]59 through the format operator rather than typed literally. This # file is checked by resources/build/validate_script_quoting.py. # Kernel, session, security, shell and display driver processes # A display driver container can stall the present queue rather than relieve it # EDR agents are listed here so the automatic tier never demotes a security control # The EDR block below (MsMpEng through SentinelStaticEngine) is the shared EDR # core and is kept identical, name for name, with the EDR block in the # $protected arrays in HighCPURemediationCommon and HighCPURemediationConfineCores. # resources/build/validate_protected_process_lists.py fails the build if this # tier's EDR core drifts from either CPU copy. $neverTouchProcesses = @( 'System', 'Idle', 'Registry', 'Memory Compression', 'smss', 'csrss', 'wininit', 'winlogon', 'services', 'lsass', 'svchost', 'dwm', 'explorer', 'ShellExperienceHost', 'StartMenuExperienceHost', 'SearchHost', 'MsMpEng', 'MsSense', 'MpDefenderCoreService', 'NisSrv', 'SecurityHealthService', 'CSFalconService', 'CSFalconContainer', 'SentinelAgent', 'SentinelServiceHost', 'SentinelStaticEngine', 'nvcontainer', 'NVDisplay.Container', 'nvsphelper64', 'atieclxx', 'atiesrxx', 'amdow', 'igfxEM', 'igfxHK', 'IntelCpHDCPSvc', 'OneApp.IGCC.WinService' ) # Processes that must never be slowed down # Real time encoders drop frames or write a damaged file when starved # Batch jobs are handled by the pause lever instead, where the work is preserved $noStarveProcesses = @( 'obs64', 'obs32', 'Streamlabs OBS', 'XSplit.Core', 'Camtasia', 'CamtasiaStudio', 'ms-teams', 'Teams', 'Zoom', 'CptHost', 'Webex', 'ffmpeg', 'HandBrake', 'HandBrakeCLI', 'Adobe Media Encoder', 'AfterFX', 'blender' ) # Background capture and overlay services that hold the video encode engine # Both are demand start and come back on their own the next time capture is invoked $captureServicePatterns = @( 'BcastDVRUserService*', 'GameBarPresenceWriter*' ) # Engine points on one engine of one adapter before a process is worth demoting # This is engine time per second and so does not shift with adapter count $demotionThreshold = 40 $winsatTaskPath = '\Microsoft\Windows\Maintenance\' # Durable audit sink for every elevated action this tier takes # The event log write is best effort - a blocked or missing source degrades to # standard output and never stops the remediation itself. Swap this one function if # the fleet standard is a different sink $dexAuditSource = 'DexGpuRemediation' function Write-RemediationEvent { param( [int]$EventId, [string]$EntryType, [string]$Message ) Write-Output $Message try { if (-not [System.Diagnostics.EventLog]::SourceExists($dexAuditSource)) { New-EventLog -LogName 'Application' -Source $dexAuditSource -ErrorAction Stop } Write-EventLog -LogName 'Application' -Source $dexAuditSource -EventId $EventId -EntryType $EntryType -Message $Message -ErrorAction Stop } catch { Write-Output ('audit: event log write failed - {0}' -f $_.Exception.Message) } } # # Lever 1 - end a running WinSAT graphics assessment # WinSAT runs a real Direct3D benchmark and can hold the 3D engine for minutes # Windows triggers it again by itself at the next maintenance window # $winsatTask = Get-ScheduledTask -TaskPath $winsatTaskPath -TaskName 'WinSAT' -ErrorAction SilentlyContinue if ($null -eq $winsatTask) { Write-Output 'skip: winsat task not present on this build' } elseif ($winsatTask.State -ne 'Running') { Write-Output 'skip: winsat not running' } else { Stop-ScheduledTask -TaskPath $winsatTaskPath -TaskName 'WinSAT' -ErrorAction Stop Write-RemediationEvent -EventId 4202 -EntryType 'Information' -Message 'stopped: winsat graphics assessment' } # # Lever 2 - stop background capture and overlay services # A per user service can refuse a stop request when it has dependents or when the # session owner holds it, so a failure to stop is expected and not a script fault # $runningCaptureServices = @(Get-Service -Name $captureServicePatterns -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'Running' }) if ($runningCaptureServices.Count -eq 0) { Write-Output 'skip: no capture or overlay service running' } foreach ($captureService in $runningCaptureServices) { try { Stop-Service -Name $captureService.Name -ErrorAction Stop Write-RemediationEvent -EventId 4203 -EntryType 'Information' -Message ('stopped: {0}' -f $captureService.Name) } catch { Write-Output ('skip: could not stop {0}' -f $captureService.Name) } } # # Lever 3 - lower the GPU queue priority of the heaviest consumer # The CPU priority class has no effect on the WDDM scheduler, so this uses the # graphics scheduling priority class instead, which the GPU scheduler does read # # The first sample of an interval counter can be computed against process start, # so two samples are taken and only the second is used $engineSampleSet = Get-Counter -Counter '\GPU Engine(*)\Utilization Percentage' -SampleInterval 1 -MaxSamples 2 -ErrorAction SilentlyContinue if ($null -eq $engineSampleSet) { Write-Output 'skip: gpu engine counters unavailable on this device' } else { # Sum engine indexes that share a physical adapter and an engine type # Keeping the adapter in the key stops a two adapter device double counting $engineTotals = @{} foreach ($engineSample in $engineSampleSet[-1].CounterSamples) { if ($engineSample.CookedValue -le 0) { continue } if ($engineSample.InstanceName -notmatch 'pid_(\d+)_.*phys_(\d+)_eng_\d+_engtype_(.+)$') { continue } $engineBucketKey = '{0}|{1}|{2}' -f $Matches[1], $Matches[2], $Matches[3] if ($engineTotals.ContainsKey($engineBucketKey)) { $engineTotals[$engineBucketKey] = $engineTotals[$engineBucketKey] + $engineSample.CookedValue } else { $engineTotals[$engineBucketKey] = $engineSample.CookedValue } } # Reduce to the single heaviest engine per process # Taking a maximum here rather than a sum keeps the unit absolute $peakLoadByProcessId = @{} foreach ($engineBucketKey in $engineTotals.Keys) { $bucketOwnerProcessId = [int]($engineBucketKey -split '\|')[0] $bucketLoad = $engineTotals[$engineBucketKey] if ((-not $peakLoadByProcessId.ContainsKey($bucketOwnerProcessId)) -or ($bucketLoad -gt $peakLoadByProcessId[$bucketOwnerProcessId])) { $peakLoadByProcessId[$bucketOwnerProcessId] = $bucketLoad } } # Never touch the host running this script or the agent process that dispatched it # Resolving the parent is verifiable, unlike a guessed list of agent names $dispatcherProcessId = 0 $scriptHostRecord = Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId = {0}' -f $PID) -ErrorAction SilentlyContinue if ($null -ne $scriptHostRecord) { $dispatcherProcessId = [int]$scriptHostRecord.ParentProcessId } # The process start time is captured alongside the winner so the identity can be # re-verified just before the elevated action, closing the pid reuse window $demotionTarget = $null $demotionTargetLoad = 0 $demotionTargetStartTime = $null foreach ($candidateProcessId in $peakLoadByProcessId.Keys) { if ($peakLoadByProcessId[$candidateProcessId] -lt $demotionThreshold) { continue } if ($candidateProcessId -eq $PID) { continue } if ($candidateProcessId -eq $dispatcherProcessId) { continue } $candidateProcess = Get-Process -Id $candidateProcessId -ErrorAction SilentlyContinue if ($null -eq $candidateProcess) { continue } if ($neverTouchProcesses -contains $candidateProcess.ProcessName) { continue } if ($noStarveProcesses -contains $candidateProcess.ProcessName) { continue } if ($peakLoadByProcessId[$candidateProcessId] -gt $demotionTargetLoad) { $demotionTarget = $candidateProcess $demotionTargetLoad = $peakLoadByProcessId[$candidateProcessId] $demotionTargetStartTime = $null try { $demotionTargetStartTime = $candidateProcess.StartTime } catch { $demotionTargetStartTime = $null } } } if ($null -eq $demotionTarget) { Write-Output ('skip: no eligible process above {0} engine points' -f $demotionThreshold) } else { # Guard against pid reuse between sampling and action. If the sampled process # exited and Windows recycled its pid onto a different process, the identity # below will not match and the action is abandoned rather than aimed blind $recheckProcess = Get-Process -Id $demotionTarget.Id -ErrorAction SilentlyContinue $recheckStartTime = $null if ($null -ne $recheckProcess) { try { $recheckStartTime = $recheckProcess.StartTime } catch { $recheckStartTime = $null } } if ($null -eq $recheckProcess -or $recheckProcess.ProcessName -ne $demotionTarget.ProcessName -or $recheckStartTime -ne $demotionTargetStartTime) { Write-Output 'skip: demotion target identity changed before action' return } # Full image path and file hash are resolved for the audit record. Acting on a # process name alone is spoofable, so this leaves a forensic trail even when the # name matches an expected consumer. A path allowlist is a fleet specific follow # up and is intentionally not enforced here, because legitimate GPU tools are # frequently unsigned and live in varied locations $demotionTargetPath = 'unavailable' $demotionTargetHash = 'unavailable' try { $resolvedPath = $recheckProcess.Path if (-not [string]::IsNullOrWhiteSpace($resolvedPath)) { $demotionTargetPath = $resolvedPath $demotionTargetHash = (Get-FileHash -Path $resolvedPath -Algorithm SHA256 -ErrorAction Stop).Hash } } catch { $demotionTargetHash = 'unavailable' } # Authenticode status and signer are captured for the audit trail on every # action. A process name alone is spoofable, and an unsigned binary is not by # itself suspicious (many legitimate GPU tools ship unsigned), so this is not # used as a gate in the common case. HashMismatch is different - it means the # on-disk bytes were altered after signing, which is a tamper signal rather # than a policy choice, so that specific status blocks the elevated action and # is routed to the audit log as a warning instead $demotionTargetSignatureStatus = 'unavailable' $demotionTargetSignerSubject = 'unavailable' try { if (-not [string]::IsNullOrWhiteSpace($demotionTargetPath)) { $demotionTargetSignature = Get-AuthenticodeSignature -FilePath $demotionTargetPath -ErrorAction Stop $demotionTargetSignatureStatus = $demotionTargetSignature.Status.ToString() if ($null -ne $demotionTargetSignature.SignerCertificate) { $demotionTargetSignerSubject = $demotionTargetSignature.SignerCertificate.Subject } } } catch { $demotionTargetSignatureStatus = 'unavailable' } if ($demotionTargetSignatureStatus -eq 'HashMismatch') { Write-RemediationEvent -EventId 4211 -EntryType 'Warning' -Message ('alert: signature hash mismatch for {0} pid {1} path {2} sha256 {3} - possible tampered binary, demotion skipped' -f $demotionTarget.ProcessName, $demotionTarget.Id, $demotionTargetPath, $demotionTargetHash) return } # The interop source needs quote and semicolon characters that cannot appear # in this file, so they are supplied through the format operator $graphicsSchedulerMembers = @' [System.Runtime.InteropServices.DllImport({0}gdi32.dll{0})] public static extern int D3DKMTSetProcessSchedulingPriorityClass(System.IntPtr processHandle, int priorityClass){1} [System.Runtime.InteropServices.DllImport({0}gdi32.dll{0})] public static extern int D3DKMTGetProcessSchedulingPriorityClass(System.IntPtr processHandle, out int priorityClass){1} [System.Runtime.InteropServices.DllImport({0}kernel32.dll{0})] public static extern System.IntPtr OpenProcess(int desiredAccess, bool inheritHandle, int processId){1} [System.Runtime.InteropServices.DllImport({0}kernel32.dll{0})] public static extern bool CloseHandle(System.IntPtr objectHandle){1} '@ -f [char]34, [char]59 if (-not ('DexGraphics.Scheduler' -as [type])) { Add-Type -Namespace 'DexGraphics' -Name 'Scheduler' -MemberDefinition $graphicsSchedulerMembers -ErrorAction Stop } # 0x0600 combines query information and set information access $targetProcessHandle = [DexGraphics.Scheduler]::OpenProcess(0x0600, $false, $demotionTarget.Id) if ($targetProcessHandle -eq [System.IntPtr]::Zero) { Write-Output ('skip: no access to {0}' -f $demotionTarget.ProcessName) } else { # Read before write, so a repeat run on an already demoted process does nothing # Class 1 is below normal and class 2 is normal $currentPriorityClass = -1 $queryStatus = [DexGraphics.Scheduler]::D3DKMTGetProcessSchedulingPriorityClass($targetProcessHandle, [ref]$currentPriorityClass) if ($queryStatus -eq 0 -and $currentPriorityClass -le 1 -and $currentPriorityClass -ge 0) { Write-Output ('skip: {0} already at or below normal graphics priority' -f $demotionTarget.ProcessName) } else { $applyStatus = [DexGraphics.Scheduler]::D3DKMTSetProcessSchedulingPriorityClass($targetProcessHandle, 1) if ($applyStatus -eq 0) { Write-RemediationEvent -EventId 4200 -EntryType 'Information' -Message ('demoted: {0} pid {1} at {2} engine points path {3} sha256 {4} signature {5} signer {6}' -f $demotionTarget.ProcessName, $demotionTarget.Id, [math]::Round($demotionTargetLoad, 0), $demotionTargetPath, $demotionTargetHash, $demotionTargetSignatureStatus, $demotionTargetSignerSubject) } else { Write-Output ('skip: graphics priority change refused for {0}' -f $demotionTarget.ProcessName) } } [void][DexGraphics.Scheduler]::CloseHandle($targetProcessHandle) } } }